media_pp\elements\sink\muxer/segmented_mp4_muxer.rs
1use std::{
2 path::PathBuf,
3 sync::{Arc, Mutex},
4 time::{Duration, Instant},
5};
6
7use crate::pp_log::{PpLog, pp_info};
8use ffmpeg_next as ffmpeg;
9
10use super::mp4_muxer::{Mp4Muxer, Mp4MuxerError};
11use crate::{
12 buffer::MediaBuffer,
13 control::ControlMsg,
14 element::{Element, ElementType, Sink, element_pp_log},
15 error::Result,
16};
17
18/// How a [`SegmentedMp4Muxer`] decides a segment is done and it's time to
19/// cut to a new file.
20#[derive(Debug, Clone, Copy)]
21pub enum SegmentPolicy {
22 /// Roughly this long per segment. "Roughly" because the actual cut
23 /// only happens once this much time has elapsed *and* the video
24 /// track's own next packet is a keyframe (see
25 /// [`SegmentedMp4Muxer::open`]'s own docs) — a segment can run a bit
26 /// longer than requested if keyframes are sparse.
27 Duration(Duration),
28}
29
30/// One track's fixed description — everything [`SegmentedMp4Muxer::open`]
31/// needs to re-add it to a fresh [`Mp4Muxer`] on every rotation.
32/// `parameters` is cloned each time ([`Mp4Muxer::add_stream`] consumes its
33/// own copy); the original stays here as the template.
34struct StreamDef {
35 name: Arc<str>,
36 parameters: ffmpeg::codec::Parameters,
37 time_base: ffmpeg::Rational,
38 /// Whether this is the track [`SegmentGroup::maybe_rotate`] waits for
39 /// a keyframe on before actually cutting — see
40 /// [`SegmentedMp4Muxer::open`]'s own docs.
41 is_video: bool,
42}
43
44/// Builds a [`SegmentedMp4Muxer`] the same two-phase way as a plain
45/// [`Mp4Muxer`] (every track's shape must be known before the first byte
46/// is written) — `create` picks the rotation policy and how segments get
47/// named, `add_stream` registers each track exactly like
48/// [`Mp4Muxer::add_stream`], `open` writes the first segment's header and
49/// returns one [`Sink`] per track.
50///
51/// ```ignore
52/// let mut muxer = SegmentedMp4Muxer::create(
53/// SegmentPolicy::Duration(Duration::from_secs(600)),
54/// |index| PathBuf::from(format!("rec_{index:04}.mp4")),
55/// );
56/// muxer.add_stream("video", video_encoder.parameters(), video_time_base);
57/// muxer.add_stream("audio", audio_encoder.parameters(), audio_time_base);
58/// let mut sinks = muxer.open()?;
59/// ```
60pub struct SegmentedMp4Muxer {
61 policy: SegmentPolicy,
62 naming: Box<dyn FnMut(u64) -> PathBuf + Send>,
63 streams: Vec<StreamDef>,
64}
65
66impl SegmentedMp4Muxer {
67 /// `naming(index)` names each segment file, `index` starting at `0` —
68 /// called once up front for the first segment and again on every
69 /// rotation. Typically a closure building a path from a fixed
70 /// directory/prefix (e.g. `|i| dir.join(format!("rec_{i:04}.mp4"))`);
71 /// a timestamp-based scheme works just as well since `index` is only
72 /// ever used to *call* this, never to build the path itself.
73 pub fn create(
74 policy: SegmentPolicy,
75 naming: impl FnMut(u64) -> PathBuf + Send + 'static,
76 ) -> Self {
77 Self {
78 policy,
79 naming: Box::new(naming),
80 streams: Vec::new(),
81 }
82 }
83
84 /// Registers one more track every segment file will hold — same
85 /// contract as [`Mp4Muxer::add_stream`] (same order rules, same
86 /// `name`/`parameters`/`time_base` meaning), except this can't fail:
87 /// nothing here touches ffmpeg yet, it's only recorded for
88 /// [`SegmentedMp4Muxer::open`] (and every later rotation) to replay.
89 ///
90 /// Whichever stream's `parameters.medium()` is
91 /// [`ffmpeg::media::Type::Video`] (at most one is expected) becomes
92 /// the keyframe-gating track described in [`SegmentedMp4Muxer::open`]'s
93 /// own docs — no separate flag to pass.
94 pub fn add_stream(
95 &mut self,
96 name: impl Into<String>,
97 parameters: ffmpeg::codec::Parameters,
98 time_base: ffmpeg::Rational,
99 ) {
100 let is_video = parameters.medium() == ffmpeg::media::Type::Video;
101 self.streams.push(StreamDef {
102 name: name.into().into(),
103 parameters,
104 time_base,
105 is_video,
106 });
107 }
108
109 /// Writes the first segment's header and returns one [`Sink`] per
110 /// track, in the order [`SegmentedMp4Muxer::add_stream`] added them —
111 /// same shape as [`Mp4Muxer::open`]. All returned `Sink`s share one
112 /// rotation lock: a track's `consume` blocks while another track (on
113 /// its own thread) is mid-rotation, same tradeoff [`Mp4Muxer`]'s own
114 /// shared file lock already makes.
115 ///
116 /// **Rotation timing**: once a segment has run at least as long as the
117 /// configured [`SegmentPolicy`], the cut happens on the *video*
118 /// track's next keyframe (found via `add_stream`'s `parameters` — see
119 /// its own docs) — not immediately, and not on an arbitrary packet —
120 /// so every segment file is independently decodable from its own
121 /// first frame, the same way a real segment/HLS muxer cuts. A segment
122 /// can therefore run somewhat longer than requested if keyframes are
123 /// sparse; there's no hard cap. If no track's `parameters.medium()`
124 /// was `Video` (an audio-only recording), any packet on any track is
125 /// an equally valid cut point, so rotation happens as soon as the
126 /// policy is due.
127 ///
128 /// The final segment is finalized the same way a plain [`Mp4Muxer`]
129 /// finalizes its one file: once every track has reported `Eos` *or*
130 /// [`ControlMsg::Stop`] (see [`Mp4Muxer::open`]'s own docs) — a
131 /// rotation mid-recording reuses that exact mechanism to close the
132 /// outgoing segment before opening the next one.
133 pub fn open(mut self) -> Result<Vec<Box<dyn Sink>>> {
134 // Captured before `self.streams` moves into `GroupState` below —
135 // just the names, in order, for building each `SegmentedTrackSink`
136 // afterward.
137 let names: Vec<Arc<str>> = self.streams.iter().map(|s| s.name.clone()).collect();
138 let path = (self.naming)(0);
139 let current_sinks = open_segment(&self.streams, path)?;
140 let group = Arc::new(SegmentGroup {
141 policy: self.policy,
142 naming: Mutex::new(self.naming),
143 state: Mutex::new(GroupState {
144 streams: self.streams,
145 current_sinks,
146 segment_index: 0,
147 segment_started: Instant::now(),
148 }),
149 });
150 Ok(names
151 .into_iter()
152 .enumerate()
153 .map(|(index, name)| -> Box<dyn Sink> {
154 Box::new(SegmentedTrackSink {
155 pp_log: element_pp_log(ElementType::SegmentedMp4Muxer, &name, None),
156 name,
157 track_index: index,
158 group: group.clone(),
159 })
160 })
161 .collect())
162 }
163}
164
165fn open_segment(streams: &[StreamDef], path: PathBuf) -> Result<Vec<Box<dyn Sink>>> {
166 let mut muxer = Mp4Muxer::create(&path)?;
167 for stream in streams {
168 muxer.add_stream(
169 stream.name.to_string(),
170 stream.parameters.clone(),
171 stream.time_base,
172 )?;
173 }
174 muxer.open()
175}
176
177struct GroupState {
178 /// This group's fixed track descriptions — moved in here (rather than
179 /// sitting on [`SegmentGroup`] directly) purely so `Mutex<GroupState>`
180 /// covers it too: [`ffmpeg::codec::Parameters`] wraps a raw pointer and
181 /// isn't `Sync`, so a field of this type living outside any `Mutex`
182 /// would make `SegmentGroup` itself `!Sync` and unable to cross
183 /// threads inside the `Arc` every [`SegmentedTrackSink`] holds.
184 /// `Mutex<T>` only ever needs `T: Send` (already true here — see
185 /// `ffmpeg-next`'s own `unsafe impl Send for Parameters`), never
186 /// `T: Sync`, which is exactly what sidesteps that.
187 streams: Vec<StreamDef>,
188 /// The currently-open segment's own per-track sinks, in the same
189 /// order as `streams` — index-aligned with
190 /// [`SegmentedTrackSink::track_index`].
191 current_sinks: Vec<Box<dyn Sink>>,
192 segment_index: u64,
193 segment_started: Instant,
194}
195
196/// Shared between every [`SegmentedTrackSink`] [`SegmentedMp4Muxer::open`]
197/// hands out — one rotation lock around the whole group of tracks, so a
198/// rotation triggered by one track's packet arrival is atomic with respect
199/// to every other track (either all of them are still writing into the
200/// outgoing segment, or all of them are already writing into the new one —
201/// never a mix).
202struct SegmentGroup {
203 policy: SegmentPolicy,
204 naming: Mutex<Box<dyn FnMut(u64) -> PathBuf + Send>>,
205 state: Mutex<GroupState>,
206}
207
208impl SegmentGroup {
209 /// Called for every `Packet` on every track, before it's written.
210 /// Rotates first if this is the packet that should trigger it (see
211 /// [`SegmentedMp4Muxer::open`]'s own docs), then writes into whichever
212 /// segment is current by the time this returns.
213 fn consume_packet(
214 &self,
215 track_index: usize,
216 packet: Arc<ffmpeg::Packet>,
217 pp_log: &PpLog,
218 ) -> Result<()> {
219 let mut state = self.state.lock().unwrap();
220 let SegmentPolicy::Duration(due_after) = self.policy;
221 let mut rotated_to = None;
222 if state.segment_started.elapsed() >= due_after {
223 let has_video = state.streams.iter().any(|s| s.is_video);
224 let this_is_video = state.streams[track_index].is_video;
225 let should_cut = if has_video {
226 this_is_video && packet.is_key()
227 } else {
228 true
229 };
230 if should_cut {
231 for sink in state.current_sinks.iter_mut() {
232 sink.control(ControlMsg::Stop)?;
233 }
234 let index = state.segment_index + 1;
235 let path = (self.naming.lock().unwrap())(index);
236 state.current_sinks = open_segment(&state.streams, path)?;
237 state.segment_index = index;
238 state.segment_started = Instant::now();
239 rotated_to = Some(index);
240 }
241 }
242 let result = state.current_sinks[track_index].consume(MediaBuffer::Packet(packet));
243 // Formatting and emitting happen off the group lock: every track's
244 // `consume_packet` contends for it, so nothing that isn't required
245 // to be serialized with the rotation belongs inside it.
246 drop(state);
247 if let Some(index) = rotated_to {
248 pp_info!(pp_log: pp_log, "rotated segment_index={index}");
249 }
250 result
251 }
252
253 /// One track's own natural `Eos` — forwarded into whatever segment is
254 /// current, same as [`SegmentGroup::consume_packet`] but without a
255 /// rotation check (ending is ending, not a cut point).
256 fn finish_eos(&self, track_index: usize) -> Result<()> {
257 let mut state = self.state.lock().unwrap();
258 state.current_sinks[track_index].consume(MediaBuffer::Eos)
259 }
260
261 /// One track's own [`ControlMsg::Stop`] — same as
262 /// [`SegmentGroup::finish_eos`], just forwarded as `Stop` instead of
263 /// `Eos` (matters for `Mp4Muxer`'s own docs on `Stop` finalizing a
264 /// container even though it otherwise means "abandon, don't drain").
265 fn finish_stop(&self, track_index: usize) -> Result<()> {
266 let mut state = self.state.lock().unwrap();
267 state.current_sinks[track_index].control(ControlMsg::Stop)
268 }
269}
270
271/// One track's own [`Sink`] — a lightweight handle sharing a
272/// [`SegmentGroup`] with every other track [`SegmentedMp4Muxer::open`]
273/// returned alongside it.
274struct SegmentedTrackSink {
275 pp_log: PpLog,
276 name: Arc<str>,
277 track_index: usize,
278 group: Arc<SegmentGroup>,
279}
280
281impl Element for SegmentedTrackSink {
282 fn name(&self) -> Arc<str> {
283 self.name.clone()
284 }
285
286 fn element_type(&self) -> ElementType {
287 ElementType::SegmentedMp4Muxer
288 }
289
290 fn pp_log(&self) -> &PpLog {
291 &self.pp_log
292 }
293
294 fn pp_log_mut(&mut self) -> &mut PpLog {
295 &mut self.pp_log
296 }
297}
298
299impl Sink for SegmentedTrackSink {
300 fn consume(&mut self, buf: MediaBuffer) -> Result<()> {
301 match buf {
302 MediaBuffer::Packet(packet) => {
303 self.group
304 .consume_packet(self.track_index, packet, &self.pp_log)
305 }
306 MediaBuffer::Eos => self.group.finish_eos(self.track_index),
307 // The `Mp4Muxer` each rotated segment wraps already rejects
308 // this — matching its own `Mp4MuxerStreamSink::consume` here
309 // instead of silently no-op'ing keeps that protection visible
310 // through the rotation wrapper instead of swallowing it.
311 other => Err(Mp4MuxerError::UnsupportedBuffer(other.kind()).into()),
312 }
313 }
314
315 fn control(&mut self, msg: ControlMsg) -> Result<()> {
316 if msg == ControlMsg::Stop {
317 self.group.finish_stop(self.track_index)?;
318 }
319 Ok(())
320 }
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326 use crate::{
327 elements::{SwEncoder, SwEncoderOptions, TestVideoOptions, TestVideoSource, VideoCodec},
328 pipeline::Pipeline,
329 };
330
331 /// Drives a real `TestVideoSource -> SwEncoder -> SegmentedMp4Muxer`
332 /// chain for a few real seconds with a short rotation policy, then
333 /// checks every segment file it produced: each one has to be a real,
334 /// independently-readable `.mp4` whose very first packet is a
335 /// keyframe — proof the cut actually waited for one (see
336 /// `SegmentedMp4Muxer::open`'s own docs on why that matters: cutting
337 /// on an arbitrary packet would leave a segment starting mid-GOP,
338 /// undecodable from its own frame 0).
339 #[test]
340 fn rotates_into_multiple_valid_keyframe_aligned_segments() {
341 let video_options = TestVideoOptions {
342 width: 160,
343 height: 120,
344 framerate: ffmpeg::Rational::new(15, 1),
345 };
346 let video_source = TestVideoSource::new("video", video_options);
347 let time_base = video_source.time_base();
348 let encoder = SwEncoder::new(
349 "encoder",
350 SwEncoderOptions {
351 codec: VideoCodec::OpenH264,
352 width: video_options.width,
353 height: video_options.height,
354 time_base,
355 frame_rate: video_options.framerate,
356 bit_rate: 200_000,
357 // Short on purpose (~0.5s @ 15fps) — this test needs
358 // several real keyframes to show up quickly, not the
359 // ~2s default every other caller uses.
360 gop_size: 8,
361 },
362 )
363 .expect("openh264 encoder must be available");
364
365 let dir = std::env::temp_dir();
366 let prefix = format!("segmented_mp4_test_{}", std::process::id());
367 let paths: Arc<Mutex<Vec<PathBuf>>> = Arc::new(Mutex::new(Vec::new()));
368 let recorded_paths = paths.clone();
369
370 let mut muxer = SegmentedMp4Muxer::create(
371 SegmentPolicy::Duration(Duration::from_millis(300)),
372 move |index| {
373 let path = dir.join(format!("{prefix}_{index:03}.mp4"));
374 recorded_paths.lock().unwrap().push(path.clone());
375 path
376 },
377 );
378 muxer.add_stream("video", encoder.parameters(), time_base);
379 let mut sinks = muxer.open().expect("open must succeed");
380 let sink = sinks.pop().expect("exactly one stream was added");
381
382 let pipeline = Pipeline::new("segmented-test", video_source, |source, ctx| {
383 let branch = ctx.branch().pipe(encoder).to(sink)?;
384 ctx.attach(source, 0, branch)?;
385 Ok(())
386 })
387 .expect("test pipeline wiring must succeed");
388 pipeline.run();
389 std::thread::sleep(Duration::from_secs(3));
390 pipeline.stop();
391 pipeline.bus().log_events();
392
393 let paths = paths.lock().unwrap().clone();
394 assert!(
395 paths.len() >= 2,
396 "expected at least 2 segments, got {}: {paths:?}",
397 paths.len()
398 );
399
400 for path in &paths {
401 let mut input = ffmpeg::format::input(path)
402 .unwrap_or_else(|error| panic!("segment {path:?} must be readable: {error}"));
403 assert_eq!(
404 input.streams().count(),
405 1,
406 "segment {path:?} should have exactly one stream"
407 );
408 let mut packet = ffmpeg::Packet::empty();
409 if packet.read(&mut input).is_err() {
410 panic!("segment {path:?} has no packets at all");
411 }
412 assert!(
413 packet.is_key(),
414 "segment {path:?}'s first packet must be a keyframe"
415 );
416 }
417
418 for path in &paths {
419 std::fs::remove_file(path).ok();
420 }
421 }
422
423 /// A misrouted `Audio` buffer used to be silently dropped by
424 /// `SegmentedTrackSink::consume`. The `Mp4Muxer` each segment wraps
425 /// already rejects this via `Mp4MuxerError::UnsupportedBuffer` — the
426 /// rotation wrapper must surface that instead of swallowing it.
427 #[test]
428 fn rejects_a_buffer_type_the_wrapped_mp4_muxer_does_not_accept() {
429 let video_options = TestVideoOptions {
430 width: 160,
431 height: 120,
432 framerate: ffmpeg::Rational::new(15, 1),
433 };
434 let video_source = TestVideoSource::new("video", video_options);
435 let time_base = video_source.time_base();
436 let encoder = SwEncoder::new(
437 "encoder",
438 SwEncoderOptions {
439 codec: VideoCodec::OpenH264,
440 width: video_options.width,
441 height: video_options.height,
442 time_base,
443 frame_rate: video_options.framerate,
444 bit_rate: 200_000,
445 gop_size: 8,
446 },
447 )
448 .expect("openh264 encoder must be available");
449
450 let path = std::env::temp_dir().join(format!(
451 "segmented_mp4_reject_test_{}.mp4",
452 std::process::id()
453 ));
454 let recorded_path = path.clone();
455 let mut muxer = SegmentedMp4Muxer::create(
456 SegmentPolicy::Duration(Duration::from_secs(3600)),
457 move |_index| recorded_path.clone(),
458 );
459 muxer.add_stream("video", encoder.parameters(), time_base);
460 let mut sinks = muxer.open().expect("open must succeed");
461 let mut sink = sinks.pop().expect("exactly one stream was added");
462
463 let error = sink
464 .consume(MediaBuffer::Audio(Arc::new(ffmpeg::frame::Audio::empty())))
465 .expect_err("an Audio buffer must be rejected, not silently dropped");
466 assert!(
467 matches!(
468 error,
469 crate::error::Error::Mp4MuxerError(Mp4MuxerError::UnsupportedBuffer("Audio"))
470 ),
471 "unexpected error: {error:?}"
472 );
473
474 drop(sink);
475 std::fs::remove_file(&path).ok();
476 }
477
478 /// Regression test against a leaked file handle on the *previous*
479 /// segment specifically: a rotation has to fully close (write the
480 /// trailer, drop the underlying `Mp4Muxer` for that segment) the
481 /// outgoing file the moment it cuts — not defer that until the whole
482 /// recording later stops. Proven by reading the first segment back
483 /// *while the pipeline is still running* (recording into the second
484 /// one) — if `SegmentGroup::consume_packet` kept anything from the old
485 /// segment alive past the cut, this would find it still unreadable
486 /// (or, on Windows, fail to even open for read at all due to a
487 /// lingering write lock).
488 #[test]
489 fn old_segment_is_released_immediately_not_deferred_until_the_whole_recording_stops() {
490 let video_options = TestVideoOptions {
491 width: 160,
492 height: 120,
493 framerate: ffmpeg::Rational::new(15, 1),
494 };
495 let video_source = TestVideoSource::new("video", video_options);
496 let time_base = video_source.time_base();
497 let encoder = SwEncoder::new(
498 "encoder",
499 SwEncoderOptions {
500 codec: VideoCodec::OpenH264,
501 width: video_options.width,
502 height: video_options.height,
503 time_base,
504 frame_rate: video_options.framerate,
505 bit_rate: 200_000,
506 gop_size: 8, // ~0.5s @ 15fps — see the other test's own note
507 },
508 )
509 .expect("openh264 encoder must be available");
510
511 let dir = std::env::temp_dir();
512 let prefix = format!("segmented_mp4_release_test_{}", std::process::id());
513 let paths: Arc<Mutex<Vec<PathBuf>>> = Arc::new(Mutex::new(Vec::new()));
514 let recorded_paths = paths.clone();
515
516 let mut muxer = SegmentedMp4Muxer::create(
517 SegmentPolicy::Duration(Duration::from_millis(300)),
518 move |index| {
519 let path = dir.join(format!("{prefix}_{index:03}.mp4"));
520 recorded_paths.lock().unwrap().push(path.clone());
521 path
522 },
523 );
524 muxer.add_stream("video", encoder.parameters(), time_base);
525 let mut sinks = muxer.open().expect("open must succeed");
526 let sink = sinks.pop().expect("exactly one stream was added");
527
528 let pipeline = Pipeline::new("segmented-release-test", video_source, |source, ctx| {
529 let branch = ctx.branch().pipe(encoder).to(sink)?;
530 ctx.attach(source, 0, branch)?;
531 Ok(())
532 })
533 .expect("test pipeline wiring must succeed");
534 pipeline.run();
535
536 // Wait (bounded) for at least one rotation — the pipeline is
537 // deliberately still running past this point.
538 let waited = Instant::now();
539 loop {
540 if paths.lock().unwrap().len() >= 2 {
541 break;
542 }
543 assert!(
544 waited.elapsed() < Duration::from_secs(5),
545 "no rotation happened within 5s"
546 );
547 std::thread::sleep(Duration::from_millis(50));
548 }
549
550 let first_segment = paths.lock().unwrap()[0].clone();
551 let mut input = ffmpeg::format::input(&first_segment).unwrap_or_else(|error| {
552 panic!("segment 0 must already be readable while still recording segment 1: {error}")
553 });
554 let mut packet = ffmpeg::Packet::empty();
555 assert!(
556 packet.read(&mut input).is_ok(),
557 "segment 0 must have packets"
558 );
559 drop(input);
560
561 pipeline.stop();
562 pipeline.bus().log_events();
563
564 for path in paths.lock().unwrap().iter() {
565 std::fs::remove_file(path).ok();
566 }
567 }
568}